@alfacapital-tech/c4builder 0.3.0 → 0.4.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.MD +167 -5
- package/dist/cli/commands/check.js +80 -0
- package/dist/cli/commands/help.js +4 -0
- package/dist/cli/commands/list.js +3 -0
- package/dist/cli/dispatch.js +35 -9
- package/dist/config/options.js +6 -0
- package/dist/config/schema.js +8 -0
- package/dist/core/build.js +11 -2
- package/dist/core/plugins/assets.js +53 -0
- package/dist/core/plugins/hooks.js +30 -0
- package/dist/core/plugins/load.js +125 -0
- package/dist/core/plugins/source.js +86 -0
- package/dist/core/plugins/tree.js +83 -0
- package/dist/core/plugins/types.js +2 -0
- package/dist/core/render/d2renderer.js +24 -11
- package/dist/core/render/diagrams.js +11 -6
- package/dist/core/render/jre.js +5 -58
- package/dist/plugins/index.js +4 -0
- package/dist/plugins/openapi/index.js +95 -0
- package/dist/plugins/openspec/fences.js +55 -0
- package/dist/plugins/openspec/index.js +73 -0
- package/dist/plugins/openspec/render.js +216 -0
- package/dist/plugins/openspec/scan.js +99 -0
- package/dist/util/archive.js +61 -0
- package/dist/util/glob.js +66 -0
- package/package.json +1 -1
- package/vendor/swagger-ui/README.md +6 -0
- package/vendor/swagger-ui/swagger-ui.css +3 -0
package/README.MD
CHANGED
|
@@ -94,6 +94,29 @@ Options:
|
|
|
94
94
|
There is also a `jre` subcommand: `c4builder jre install [--force]` pre-downloads the
|
|
95
95
|
private JRE cache, `c4builder jre info` prints which Java would be used.
|
|
96
96
|
|
|
97
|
+
### Validating single diagrams
|
|
98
|
+
|
|
99
|
+
`c4builder check <file...>` validates individual `.puml` / `.iuml` / `.d2` files with the
|
|
100
|
+
same engines the build uses (bundled PlantUML jar + system/managed Java, bundled D2) —
|
|
101
|
+
no project or `.c4builder` needed, nothing is written. Handy for pre-commit hooks and CI:
|
|
102
|
+
exit code 0 when every file compiles, 1 otherwise; errors are printed as
|
|
103
|
+
`✗ file: line N: <engine message>`.
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
c4builder check src/c4/context.puml lib/shared.iuml diagrams/flow.d2
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`!include` paths resolve relative to the checked file's directory; a `.iuml` library is
|
|
110
|
+
validated by including it into an empty diagram.
|
|
111
|
+
|
|
112
|
+
A minimal pre-commit hook (`.git/hooks/pre-commit`) that validates staged diagrams:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
#!/bin/sh
|
|
116
|
+
files=$(git diff --cached --name-only --diff-filter=ACM -- '*.puml' '*.iuml' '*.d2')
|
|
117
|
+
[ -z "$files" ] || exec c4builder check $files
|
|
118
|
+
```
|
|
119
|
+
|
|
97
120
|
By default, running `c4builder` will attempt to build the project.
|
|
98
121
|
|
|
99
122
|
If no configuration was set it will ask for it and then do the actual build. Running just `c4builder` after a configuration will no longer show the wizard.
|
|
@@ -263,6 +286,124 @@ By default this is disabled. If enabled, the output will no longer include the a
|
|
|
263
286
|
|
|
264
287
|
The current entity structure is inserted after the header
|
|
265
288
|
|
|
289
|
+
## Plugins
|
|
290
|
+
|
|
291
|
+
Plugins extend the build with content from outside `rootFolder` (virtual pages that go through
|
|
292
|
+
the regular pipeline: sidebar, search, `generateMD`, complete markdown, local diagram render)
|
|
293
|
+
and with site assets. They are listed in `.c4builder` under `plugins` — either `"name"` or
|
|
294
|
+
`["name", { ...options }]`:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
{
|
|
298
|
+
"plugins": [
|
|
299
|
+
"openspec",
|
|
300
|
+
["openapi", { "dir": "../contracts", "glob": "*/openapi.yaml" }]
|
|
301
|
+
]
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
A plugin identifier resolves in this order: a built-in name (`openspec`, `openapi`) → a local
|
|
306
|
+
ESM module (`./plugins/my.mjs`, `../shared/plugin.mjs`, absolute path) → an npm package resolved
|
|
307
|
+
from the project directory. Options are validated by the plugin's own zod schema **before** the
|
|
308
|
+
build starts (built-in plugins are strict — an unknown option key is an error); `${ENV_VAR}`
|
|
309
|
+
placeholders inside string options are replaced from the environment (unset → empty string) and
|
|
310
|
+
never printed (archive URLs appear in messages without their query string, so a token passed as
|
|
311
|
+
`?private_token=${TOKEN}` stays out of the logs). A malformed `plugins` entry always fails the
|
|
312
|
+
build (it is the one key the wizard cannot repair). Older c4builder versions ignore the `plugins`
|
|
313
|
+
key, so a rollback keeps building.
|
|
314
|
+
|
|
315
|
+
### `openspec` — publish a local OpenSpec store
|
|
316
|
+
|
|
317
|
+
Renders an [OpenSpec](https://github.com/Fission-AI/OpenSpec) store as a site section:
|
|
318
|
+
a summary (counters, task progress), a page per active change, the specs tree and the archive.
|
|
319
|
+
A change page shows the header (schema, dates, task progress, links to subpages) plus the first
|
|
320
|
+
artifact of `artifacts` (by default `proposal` — the "why" is visible without a click); the
|
|
321
|
+
remaining artifacts (`design`, `tasks`, then any other `*.md` alphabetically) are subpages, and
|
|
322
|
+
spec deltas live under `specs/…` mirroring the folder layout (a folder that has its own
|
|
323
|
+
`spec.md` and nested specs gets one page: the spec followed by the list of nested ones).
|
|
324
|
+
```` ```plantuml ```` / ```` ```d2 ```` fences inside artifacts, deltas and store specs are
|
|
325
|
+
rendered by the **local** engines (nothing is sent to plantuml.com); the image name is derived
|
|
326
|
+
from the fence content, so inserting or reordering fences never reuses a neighbour's cached
|
|
327
|
+
image. Relative links between artifacts of a change point to their pages, referenced files
|
|
328
|
+
(images, notes) are copied next to the page (and to the output root for the complete markdown;
|
|
329
|
+
`excludeOtherFiles` applies). The store folder is watched in `-w`.
|
|
330
|
+
|
|
331
|
+
| Option | Default | Meaning |
|
|
332
|
+
|-------------|--------------------------------|----------------------------------------------------------------|
|
|
333
|
+
| `dir` | `openspec` | store folder (relative to the project directory) |
|
|
334
|
+
| `mount` | `OpenSpec` | section name (and its path in the output folder) |
|
|
335
|
+
| `artifacts` | `["proposal","design","tasks"]`| artifact order; the first one is shown on the change page itself |
|
|
336
|
+
|
|
337
|
+
### `openapi` — swagger-ui pages for a set of OpenAPI specs
|
|
338
|
+
|
|
339
|
+
Finds specs by `glob` in a local folder (`dir`) **or** in a `tar.gz`/`zip` archive fetched over
|
|
340
|
+
HTTP (`archive`, e.g. a GitLab/GitHub repository archive URL; `subdir` picks a folder inside it,
|
|
341
|
+
`headers` adds request headers such as `{"PRIVATE-TOKEN": "${GITLAB_TOKEN}"}` — empty values are
|
|
342
|
+
dropped, so the same config works with and without a token). One page per spec, named after the
|
|
343
|
+
spec's parent folder (file name for specs in the source root), plus an index. All `yaml`/`yml`/
|
|
344
|
+
`json` files of the source are copied into `<mount>/_specs/…` keeping their relative layout, so
|
|
345
|
+
relative `$ref`s (including shared schema files that do not match `glob`) keep resolving; pages
|
|
346
|
+
load swagger-ui from the bundled `vendor/` files — no CDN, no access to the source repository is
|
|
347
|
+
needed by readers. The plugin forces `executeScript` (logged) and injects `swagger-ui.css` and
|
|
348
|
+
`swagger-ui-bundle.js` into `index.html` after the template is rendered (skipped when the template
|
|
349
|
+
already links the bundle), so custom `docsifyTemplate`s need no change. The output folder is never
|
|
350
|
+
treated as a source (`dir: "."` is fine). Archives are downloaded once per process (watch mode
|
|
351
|
+
reuses them).
|
|
352
|
+
|
|
353
|
+
| Option | Default | Meaning |
|
|
354
|
+
|-----------|----------------------------------|------------------------------------------|
|
|
355
|
+
| `mount` | `API` | section name |
|
|
356
|
+
| `dir` | — | local folder with specs (watched in `-w`)|
|
|
357
|
+
| `archive` | — | HTTP(S) URL of a tar.gz/zip archive |
|
|
358
|
+
| `subdir` | — | folder inside the archive / `dir` |
|
|
359
|
+
| `headers` | — | HTTP headers for the archive request |
|
|
360
|
+
| `glob` | `**/openapi.{yaml,yml,json}` | spec pattern (`**`, `*`, `?`, `{a,b}`) |
|
|
361
|
+
|
|
362
|
+
Example for a repository that keeps its `openspec/` store next to the C4 sources and consumes
|
|
363
|
+
contracts from another repository:
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{
|
|
367
|
+
"plugins": [
|
|
368
|
+
"openspec",
|
|
369
|
+
["openapi", {
|
|
370
|
+
"archive": "https://gitlab.example.com/api/v4/projects/123/repository/archive.tar.gz?sha=main",
|
|
371
|
+
"glob": "*/openapi.yaml",
|
|
372
|
+
"headers": { "PRIVATE-TOKEN": "${GITLAB_TOKEN}" }
|
|
373
|
+
}]
|
|
374
|
+
]
|
|
375
|
+
}
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Writing a plugin
|
|
379
|
+
|
|
380
|
+
A plugin is an ESM module whose default export is a plain object (use `definePlugin` from
|
|
381
|
+
`@alfacapital-tech/c4builder/dist/core/plugins/types.js` for typing):
|
|
382
|
+
|
|
383
|
+
```js
|
|
384
|
+
import { z } from 'zod';
|
|
385
|
+
export default {
|
|
386
|
+
name: 'changelog',
|
|
387
|
+
options: z.object({ file: z.string().default('CHANGELOG.md') }).strict(), // optional
|
|
388
|
+
watchPaths: (opts) => [opts.file], // optional
|
|
389
|
+
assets: { styles: ['/abs/path/x.css'], scripts: ['/abs/path/x.js'] }, // optional
|
|
390
|
+
requires: { executeScript: true }, // optional
|
|
391
|
+
async afterScan(ctx, opts) {
|
|
392
|
+
// ctx.tree, ctx.options — the build tree/options; ctx.source({ dir } | { archive, subdir, headers })
|
|
393
|
+
// resolves a folder; ctx.addPage adds a virtual page (missing parents become index pages).
|
|
394
|
+
ctx.addPage({
|
|
395
|
+
path: ['Changelog'],
|
|
396
|
+
markdown: '## Latest\n\n',
|
|
397
|
+
diagrams: [{ file: 'flow.puml', content: '@startuml\nA -> B\n@enduml' }]
|
|
398
|
+
});
|
|
399
|
+
},
|
|
400
|
+
async afterBuild(ctx, opts) { /* ctx.distFolder, ctx.options — copy extra files, run checks */ }
|
|
401
|
+
};
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
Hooks run in the order of the `plugins` list; an exception aborts the build with the plugin's
|
|
405
|
+
name (the previous `dist` backup is kept as usual).
|
|
406
|
+
|
|
266
407
|
# Future plans
|
|
267
408
|
|
|
268
409
|
I would like to bind the **build** phase to the C4 model. It could parse the diagrams and know what Person/System/Component/Container is related to what and help with additional validation. Maybe even export to [structurizr](https://structurizr.com/) or vice versa.
|
|
@@ -305,19 +446,24 @@ Use the image directly in CI (GitHub Actions, GitLab CI) as the job image — th
|
|
|
305
446
|
Publishing is triggered by pushing a git tag `v<version>` — pushes/merges to `master` alone
|
|
306
447
|
do not publish.
|
|
307
448
|
|
|
308
|
-
|
|
449
|
+
Every user-facing PR adds a line to the `## Unreleased` section of the change log below;
|
|
450
|
+
the release commit renames it to `## vX.Y.Z`.
|
|
451
|
+
|
|
452
|
+
**Release candidate** (from `master`, as many as needed — consumers pinned to `rc`
|
|
453
|
+
pick each one up automatically):
|
|
309
454
|
|
|
310
455
|
```
|
|
311
|
-
npm version
|
|
456
|
+
npm version X.Y.Z-rc.1 -m "rc: %s" # first candidate
|
|
457
|
+
npm version prerelease -m "rc: %s" # next ones: rc.2, rc.3, …
|
|
312
458
|
git push --follow-tags
|
|
313
459
|
```
|
|
314
460
|
|
|
315
|
-
Publishes to dist-tag `rc
|
|
461
|
+
Publishes to npm dist-tag `rc` and docker tag `rc`; `latest` is untouched.
|
|
316
462
|
|
|
317
|
-
**Final release
|
|
463
|
+
**Final release** (rename `## Unreleased` → `## vX.Y.Z` first):
|
|
318
464
|
|
|
319
465
|
```
|
|
320
|
-
npm version
|
|
466
|
+
npm version X.Y.Z -m "release: %s"
|
|
321
467
|
git push --follow-tags
|
|
322
468
|
```
|
|
323
469
|
|
|
@@ -325,6 +471,22 @@ Publishes to dist-tag `latest`.
|
|
|
325
471
|
|
|
326
472
|
# Change log
|
|
327
473
|
|
|
474
|
+
## Unreleased
|
|
475
|
+
|
|
476
|
+
- Plugin system: `plugins` key in `.c4builder`, `afterScan`/`afterBuild` hooks, virtual pages,
|
|
477
|
+
plugin assets injected into `index.html`, `dir`/`archive` source resolver (see «Plugins»)
|
|
478
|
+
- Built-in plugins: `openspec` (local OpenSpec store → site section, local diagram render) and
|
|
479
|
+
`openapi` (offline swagger-ui pages for a set of OpenAPI specs; `vendor/swagger-ui/swagger-ui.css` 5.32.1)
|
|
480
|
+
- `c4builder check <file...>` — validate individual `.puml` / `.iuml` / `.d2` files with the
|
|
481
|
+
bundled engines, without a project build (#13)
|
|
482
|
+
- D2 compile errors are printed as readable `file:line:col: message` lines instead of raw JSON
|
|
483
|
+
- Regression test for two adjacent `` embeds (#12 — the bug itself was fixed in 0.3.0)
|
|
484
|
+
- Diagram cache key now includes the output path: diagrams that swap contents (or fence blocks
|
|
485
|
+
that shift position) are re-rendered instead of silently reusing a neighbour's cached image.
|
|
486
|
+
The first build after upgrading re-renders everything once.
|
|
487
|
+
- Watch mode ignores the output folder, its backup and `.c4builder*`, and de-duplicates nested
|
|
488
|
+
watch roots (a plugin `dir` covering the project no longer loops the rebuild)
|
|
489
|
+
|
|
328
490
|
## v0.3.0
|
|
329
491
|
|
|
330
492
|
Full TypeScript rewrite (ESM, Node.js ≥ 20.19) with the same CLI and `.c4builder` format.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { VENDOR_DIR } from "../../util/paths.js";
|
|
5
|
+
import { VENDORED_JAR } from "../../util/utils.js";
|
|
6
|
+
import { resolveJava } from "../../core/render/jre.js";
|
|
7
|
+
import { renderDiagram } from "../../core/render/diagrams.js";
|
|
8
|
+
import { renderD2, teardownD2 } from "../../core/render/d2renderer.js";
|
|
9
|
+
// `c4builder check <file...>` — валидация отдельных диаграмм ТЕМ ЖЕ движком, что и
|
|
10
|
+
// сборка (вендорный PlantUML jar + системная/managed java, бандл D2), без проекта и
|
|
11
|
+
// `.c4builder`. Рендер идёт в память и выбрасывается: проверяем ровно то, что упало бы
|
|
12
|
+
// в сборке (системный `plantuml` может отличаться версией). Код выхода 0 — все файлы ок,
|
|
13
|
+
// 1 — есть ошибка (движка, неизвестного расширения или чтения файла).
|
|
14
|
+
// stderr PlantUML в -pipe: `ERROR` / позиция / описание. Позиция — 0-based строка
|
|
15
|
+
// диаграммы, для файла с одной диаграммой это file:N+1.
|
|
16
|
+
// ponytail: в файле с несколькими @startuml позиция считается от начала блока — смещение
|
|
17
|
+
// не учитываем, добавить, если такие файлы станут проверять.
|
|
18
|
+
const PUML_ERROR = /^ERROR\n(\d+)\n(.*)$/m;
|
|
19
|
+
const JAR_PATH = path.join(VENDOR_DIR, VENDORED_JAR.jar);
|
|
20
|
+
// JRE резолвится лениво и один раз: список только из .d2 java не трогает.
|
|
21
|
+
let javaPromise = null;
|
|
22
|
+
const getJava = () => {
|
|
23
|
+
javaPromise ??= resolveJava({ log: (m) => console.log(chalk.gray(m)) }).then((r) => r.path);
|
|
24
|
+
return javaPromise;
|
|
25
|
+
};
|
|
26
|
+
const checkPlantUml = async (abs) => {
|
|
27
|
+
// .iuml — библиотека без @startuml: сам по себе PlantUML «рендерит» его без ошибок,
|
|
28
|
+
// поэтому проверяем через включение в пустую диаграмму.
|
|
29
|
+
const isLib = path.extname(abs) === '.iuml';
|
|
30
|
+
const content = isLib ? `@startuml\n!include ${abs}\n@enduml\n` : await readFile(abs);
|
|
31
|
+
const isDitaa = /@startditaa/i.test(content.toString());
|
|
32
|
+
try {
|
|
33
|
+
await renderDiagram(content, {
|
|
34
|
+
javaBin: await getJava(),
|
|
35
|
+
jarPath: JAR_PATH,
|
|
36
|
+
includePath: path.dirname(abs),
|
|
37
|
+
format: isDitaa ? 'png' : 'svg',
|
|
38
|
+
charset: 'UTF-8',
|
|
39
|
+
isDitaa,
|
|
40
|
+
useSystemFonts: false
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
const m = e.message.match(PUML_ERROR);
|
|
45
|
+
if (!m)
|
|
46
|
+
throw e;
|
|
47
|
+
// Для .iuml позиция относится к обёртке, а не к файлу — не показываем.
|
|
48
|
+
throw new Error(isLib ? m[2] : `строка ${Number(m[1]) + 1}: ${m[2]}`);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const CHECKERS = {
|
|
52
|
+
'.puml': checkPlantUml,
|
|
53
|
+
'.iuml': checkPlantUml,
|
|
54
|
+
'.d2': (abs) => renderD2(abs)
|
|
55
|
+
};
|
|
56
|
+
export default async (files) => {
|
|
57
|
+
if (files.length === 0) {
|
|
58
|
+
console.log(chalk.red('использование: c4builder check <file...> (.puml | .iuml | .d2)'));
|
|
59
|
+
process.exit(1);
|
|
60
|
+
}
|
|
61
|
+
let failed = 0;
|
|
62
|
+
// ponytail: последовательно — на сотнях файлов добавить пул как в generateImages.
|
|
63
|
+
for (const file of files) {
|
|
64
|
+
const abs = path.resolve(file);
|
|
65
|
+
const check = CHECKERS[path.extname(abs).toLowerCase()];
|
|
66
|
+
try {
|
|
67
|
+
if (!check)
|
|
68
|
+
throw new Error('неизвестное расширение (ожидается .puml, .iuml или .d2)');
|
|
69
|
+
await check(abs);
|
|
70
|
+
console.log(chalk.green(`✓ ${file}`));
|
|
71
|
+
}
|
|
72
|
+
catch (e) {
|
|
73
|
+
failed++;
|
|
74
|
+
console.error(chalk.red(`✗ ${file}: ${e.message}`));
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
await teardownD2();
|
|
78
|
+
if (failed)
|
|
79
|
+
process.exit(1);
|
|
80
|
+
};
|
|
@@ -36,6 +36,10 @@ ${chalk.cyan('Replace diagrams with a link')}
|
|
|
36
36
|
Replaces the diagram images with links.
|
|
37
37
|
${chalk.cyan('Include breadcrumbs')}
|
|
38
38
|
Shows the original folder hierarchy after each title.
|
|
39
|
+
${chalk.cyan('Plugins')} (${chalk.gray('.c4builder → "plugins", no wizard prompt')})
|
|
40
|
+
List of build plugins: "name" or ["name", { options }]. Built-in: openspec (local OpenSpec store →
|
|
41
|
+
site section), openapi (swagger-ui pages for OpenAPI specs from a folder or an HTTP archive).
|
|
42
|
+
See README «Plugins».
|
|
39
43
|
${chalk.cyan('Place diagrams before text')}
|
|
40
44
|
Choose to place diagrams before or after the text.
|
|
41
45
|
${chalk.cyan('Exclude other files')}
|
|
@@ -73,6 +73,9 @@ Charset: ${currentConfiguration.CHARSET !== undefined
|
|
|
73
73
|
Exclude other files: ${currentConfiguration.EXCLUDE_OTHER_FILES !== undefined
|
|
74
74
|
? chalk.green(currentConfiguration.EXCLUDE_OTHER_FILES)
|
|
75
75
|
: chalk.red('not set')}
|
|
76
|
+
Plugins: ${currentConfiguration.PLUGINS?.length
|
|
77
|
+
? chalk.green(currentConfiguration.PLUGINS.map((p) => (typeof p === 'string' ? p : p[0])).join(', '))
|
|
78
|
+
: chalk.gray('none')}
|
|
76
79
|
`);
|
|
77
80
|
// Битые ключи показаны выше как «not set» (щадящий разбор их выбросил); помечаем их
|
|
78
81
|
// сырым значением и причиной, чтобы было видно, что и почему требует правки/визарда.
|
package/dist/cli/dispatch.js
CHANGED
|
@@ -6,10 +6,13 @@ import Configstore from 'configstore';
|
|
|
6
6
|
import cmdHelp from "./commands/help.js";
|
|
7
7
|
import cmdNewProject from "./commands/new.js";
|
|
8
8
|
import cmdJre from "./commands/jre.js";
|
|
9
|
+
import cmdCheck from "./commands/check.js";
|
|
9
10
|
import cmdList from "./commands/list.js";
|
|
10
11
|
import cmdSite from "./commands/site.js";
|
|
11
12
|
import cmdCollect from "./wizard/collect.js";
|
|
12
13
|
import { build } from "../core/build.js";
|
|
14
|
+
import { loadPlugins, pluginWatchPaths } from "../core/plugins/load.js";
|
|
15
|
+
import fs from 'node:fs';
|
|
13
16
|
import watch from 'node-watch';
|
|
14
17
|
import { EventEmitter } from 'node:events';
|
|
15
18
|
import { clearConsole } from "../util/utils.js";
|
|
@@ -19,7 +22,7 @@ import { acquireBuildLock, BuildLockHeldError } from "../util/lock.js";
|
|
|
19
22
|
// больше не будет (одиночный запуск).
|
|
20
23
|
import { teardownD2 } from "../core/render/d2renderer.js";
|
|
21
24
|
import { packageJson as pkg } from "../util/paths.js";
|
|
22
|
-
import { parseConfig } from "../config/options.js";
|
|
25
|
+
import { outputDirs, parseConfig } from "../config/options.js";
|
|
23
26
|
import { isValidPort } from "../config/schema.js";
|
|
24
27
|
const intro = () => {
|
|
25
28
|
console.log(chalk.blue(figlet.textSync('c4builder')));
|
|
@@ -93,7 +96,8 @@ function getOptions(c) {
|
|
|
93
96
|
SUPPORT_SEARCH: c.supportSearch,
|
|
94
97
|
EXECUTE_SCRIPT: c.executeScript,
|
|
95
98
|
EXCLUDE_OTHER_FILES: c.excludeOtherFiles,
|
|
96
|
-
USE_SYSTEM_FONTS: c.useSystemFonts
|
|
99
|
+
USE_SYSTEM_FONTS: c.useSystemFonts,
|
|
100
|
+
PLUGINS: c.plugins
|
|
97
101
|
};
|
|
98
102
|
}
|
|
99
103
|
export default async () => {
|
|
@@ -118,19 +122,22 @@ export default async () => {
|
|
|
118
122
|
throw new InvalidArgumentError('ожидается TCP-порт: целое число 1..65535');
|
|
119
123
|
return Number(v);
|
|
120
124
|
})
|
|
121
|
-
.allowExcessArguments() // позиционные аргументы
|
|
125
|
+
.allowExcessArguments() // позиционные аргументы подкоманд `jre <action>` и `check <file...>`
|
|
122
126
|
.parse(process.argv);
|
|
123
127
|
const opts = program.opts();
|
|
124
128
|
// Прогрев JRE — до загрузки конфига проекта и intro: команда самостоятельна.
|
|
125
129
|
if (program.args[0] === 'jre')
|
|
126
130
|
return cmdJre(program.args.slice(1), { force: opts.force });
|
|
131
|
+
// Проверка отдельных диаграмм — тоже без конфига проекта.
|
|
132
|
+
if (program.args[0] === 'check')
|
|
133
|
+
return cmdCheck(program.args.slice(1));
|
|
127
134
|
let conf = { get: () => { } };
|
|
128
135
|
let cacheConf = { get: () => { }, set: () => { }, clear: () => { } };
|
|
129
136
|
// Лок сборки — рядом с конфигом проекта (см. util/lock.ts). Заполняется вместе с conf.
|
|
130
137
|
let lockPath = '';
|
|
138
|
+
const configPath = path.join(process.cwd(), opts.configFile ?? '.c4builder');
|
|
131
139
|
if (!opts.new) {
|
|
132
140
|
const projectKey = process.cwd().split(path.sep).splice(1).join('_');
|
|
133
|
-
const configPath = path.join(process.cwd(), opts.configFile ?? '.c4builder');
|
|
134
141
|
lockPath = `${configPath}.lock`;
|
|
135
142
|
conf = new Configstore(projectKey, {}, { configPath });
|
|
136
143
|
cacheConf = new Configstore(`${projectKey}_cache`, {}, { configPath: `${configPath}.cache` });
|
|
@@ -174,9 +181,11 @@ export default async () => {
|
|
|
174
181
|
// Повторная сборка установленного проекта (есть HAS_RUN, не --config) — строгий путь:
|
|
175
182
|
// битый конфиг обязан упасть с внятной ошибкой сразу, а не быть тихо «починен» визардом.
|
|
176
183
|
// Первый запуск (!HAS_RUN) и --config остаются щадящими — это штатный путь восстановления.
|
|
184
|
+
// `plugins` визард не спрашивает — salvage молча переписал бы его в `[]` и сборка
|
|
185
|
+
// прошла бы без раздела плагина; битый `plugins` роняет всегда.
|
|
177
186
|
const strictBuild = !opts.config && !!currentConfig.HAS_RUN;
|
|
178
187
|
if (configIssues.length) {
|
|
179
|
-
if (strictBuild)
|
|
188
|
+
if (strictBuild || configIssues.some((i) => i.key === 'plugins'))
|
|
180
189
|
failOnConfigIssues(configIssues);
|
|
181
190
|
warnConfigIssues(configIssues);
|
|
182
191
|
// Битые ключи чиним физически (salvage-значение = дефолт схемы, либо удаление):
|
|
@@ -206,13 +215,30 @@ export default async () => {
|
|
|
206
215
|
const options = getOptions(built.value);
|
|
207
216
|
if (opts.systemFonts)
|
|
208
217
|
options.USE_SYSTEM_FONTS = true; // флаг сильнее ключа конфига
|
|
218
|
+
// Плагины грузятся и валидируются до первой сборки: битые опции — exit 1 сразу.
|
|
219
|
+
const plugins = await loadPlugins(options.PLUGINS, process.cwd(), options).catch((e) => {
|
|
220
|
+
console.error(chalk.red(`Ошибка загрузки плагинов: ${e.message}`));
|
|
221
|
+
return process.exit(1);
|
|
222
|
+
});
|
|
209
223
|
const reloadEmitter = new EventEmitter();
|
|
210
224
|
reloadEmitter.setMaxListeners(0);
|
|
211
225
|
if (opts.watch) {
|
|
212
226
|
// node-watch: CJS-рантайм при ESM-.d.ts (export default) — дефолт-импорт
|
|
213
227
|
// типизируется как namespace. Каст к реальной сигнатуре, рантайм не меняется.
|
|
214
228
|
const watchDir = watch;
|
|
215
|
-
|
|
229
|
+
const inside = (p, dir) => p === dir || p.startsWith(dir + path.sep);
|
|
230
|
+
// Пути наблюдения — абсолютные и без вложенных друг в друга: node-watch дедуплицирует
|
|
231
|
+
// события по имени, а один файл под двумя корнями приходил бы дважды.
|
|
232
|
+
const candidates = [
|
|
233
|
+
path.resolve(options.ROOT_FOLDER),
|
|
234
|
+
...pluginWatchPaths(plugins, process.cwd(), fs.existsSync)
|
|
235
|
+
];
|
|
236
|
+
const watchPaths = candidates.filter((p, i) => !candidates.some((q, j) => j !== i && inside(p, q) && (p !== q || j < i)));
|
|
237
|
+
// Собственные записи сборки (dist, dist_bk, .c4builder*) не должны триггерить
|
|
238
|
+
// ребилд — при dir плагина, накрывающем cwd, это бесконечная петля.
|
|
239
|
+
const output = outputDirs(options);
|
|
240
|
+
const filter = (name, skip) => output.some((d) => inside(name, d)) || name.startsWith(configPath) ? skip : true;
|
|
241
|
+
watchDir(watchPaths, { recursive: true, filter }, async (_evt, name) => {
|
|
216
242
|
// clearConsole();
|
|
217
243
|
// intro();
|
|
218
244
|
console.log(chalk.gray(`\n${name} changed. Rebuilding...`));
|
|
@@ -229,10 +255,10 @@ export default async () => {
|
|
|
229
255
|
// каталоге не потопчет dist_bk и не потеряет чексуммы кеша.
|
|
230
256
|
const release = acquireBuildLock(lockPath);
|
|
231
257
|
try {
|
|
232
|
-
await build(options, cacheConf);
|
|
258
|
+
await build(options, cacheConf, plugins);
|
|
233
259
|
while (attemptedWatchBuild) {
|
|
234
260
|
attemptedWatchBuild = false;
|
|
235
|
-
await build(options, cacheConf);
|
|
261
|
+
await build(options, cacheConf, plugins);
|
|
236
262
|
}
|
|
237
263
|
}
|
|
238
264
|
finally {
|
|
@@ -269,7 +295,7 @@ export default async () => {
|
|
|
269
295
|
process.exit(1);
|
|
270
296
|
}
|
|
271
297
|
try {
|
|
272
|
-
await build(options, cacheConf);
|
|
298
|
+
await build(options, cacheConf, plugins);
|
|
273
299
|
}
|
|
274
300
|
finally {
|
|
275
301
|
release();
|
package/dist/config/options.js
CHANGED
|
@@ -4,7 +4,13 @@
|
|
|
4
4
|
// Форма файла `.c4builder` (camelCase) — единый тип задаёт zod-схема (config/schema),
|
|
5
5
|
// её же валидацией опирается загрузка опций сборки. Ре-экспорт: потребители типа
|
|
6
6
|
// (defaults, dispatch) продолжают импортировать его отсюда.
|
|
7
|
+
import path from 'node:path';
|
|
7
8
|
import { configSchema } from "./schema.js";
|
|
9
|
+
/** Один невалидный ключ `.c4builder`: имя ключа верхнего уровня, его сырое значение и причина. */
|
|
10
|
+
/** Суффикс бэкапа выходного каталога на время сборки (`docs` → `docs_bk`). */
|
|
11
|
+
export const DIST_BACKUP_FOLDER_SUFFIX = '_bk';
|
|
12
|
+
/** Выходные каталоги сборки (абсолютные): dist и его бэкап — их нельзя ни сканировать, ни наблюдать. */
|
|
13
|
+
export const outputDirs = (options) => [options.DIST_FOLDER, options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX].map((d) => path.resolve(d));
|
|
8
14
|
/**
|
|
9
15
|
* Разбирает сырой объект `.c4builder`, разделяя строгий и щадящий пути. Сам НЕ печатает
|
|
10
16
|
* и НЕ завершает процесс — решение (ошибка+выход либо предупреждение) принимает CLI.
|
package/dist/config/schema.js
CHANGED
|
@@ -61,6 +61,14 @@ export const configSchema = z.object({
|
|
|
61
61
|
// путей не валит сборку) — runtime дальше работает с чистым string[].
|
|
62
62
|
excludeSidebarFolderByPath: nullish(z.preprocess((v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string') : v), z.array(z.string()).optional())),
|
|
63
63
|
hasRun: optBool(),
|
|
64
|
+
// Плагины сборки: `"имя"` либо `["имя", {опции}]`. Опции здесь не валидируются —
|
|
65
|
+
// это делает схема самого плагина при загрузке (core/plugins/load); верхний уровень
|
|
66
|
+
// проверяет лишь форму записи, чтобы ошибка называла `plugins` и позицию.
|
|
67
|
+
plugins: nullish(z
|
|
68
|
+
.array(z.union([z.string(), z.tuple([z.string(), z.record(z.string(), z.unknown())])], {
|
|
69
|
+
error: 'ожидается "имя" либо ["имя", {опции}]'
|
|
70
|
+
}))
|
|
71
|
+
.default([])),
|
|
64
72
|
// Легаси-ключи старых `.c4builder` — читаются лишь для однократного предупреждения.
|
|
65
73
|
plantumlVersion: optStr(),
|
|
66
74
|
generatePDF: optBool(),
|
package/dist/core/build.js
CHANGED
|
@@ -5,14 +5,18 @@ import { makeDirectory, writeOnSameLine } from "../util/utils.js";
|
|
|
5
5
|
// clearD2FileCache: сброс кеша графа D2-импортов на границах сборки (сам webworker
|
|
6
6
|
// D2 гасит CLI после одиночной сборки — в watch-режиме он переживает ребилды).
|
|
7
7
|
import { clearD2FileCache } from "./render/d2renderer.js";
|
|
8
|
+
import { DIST_BACKUP_FOLDER_SUFFIX } from "../config/options.js";
|
|
8
9
|
// Фазы сборки: scan (дерево исходников) → render (диаграммы) → compose (markdown/сайт).
|
|
9
10
|
import { generateTree, engineSupportsRemote, clearIncludeCache } from "./scan/tree.js";
|
|
10
11
|
import { generateImages } from "./render/diagrams.js";
|
|
11
12
|
import { generateMD, generateWebMD, generateCompleteMD, clearDiagramCache } from "./compose/markdown.js";
|
|
13
|
+
// Плагины: хуки afterScan (виртуальные страницы) / afterBuild и ассеты сайта.
|
|
14
|
+
// Загружены заранее в cli/dispatch и приходят третьим аргументом (как cacheConf).
|
|
15
|
+
import { runAfterScan, runAfterBuild } from "./plugins/hooks.js";
|
|
16
|
+
import { injectPluginAssets } from "./plugins/assets.js";
|
|
12
17
|
// Оркестратор владеет lifecycle выходного каталога: dist бэкапится в dist_bk, откуда
|
|
13
18
|
// generateImages восстанавливает неизменённые по чексумме картинки; в конце бэкап сносится.
|
|
14
|
-
const
|
|
15
|
-
const build = async (options, cacheConf) => {
|
|
19
|
+
const build = async (options, cacheConf, plugins = []) => {
|
|
16
20
|
const start_date = new Date();
|
|
17
21
|
const bkFolderName = options.DIST_FOLDER + DIST_BACKUP_FOLDER_SUFFIX;
|
|
18
22
|
// Generating local images, remove old backup image folder, rename current dist folder to new backup
|
|
@@ -38,6 +42,9 @@ const build = async (options, cacheConf) => {
|
|
|
38
42
|
console.log(chalk.green(`\nbuilding documentation in ./${options.DIST_FOLDER}`));
|
|
39
43
|
const tree = await generateTree(options.ROOT_FOLDER, options);
|
|
40
44
|
console.log(chalk.blue(`parsed ${tree.length} folders`));
|
|
45
|
+
// Виртуальные страницы плагинов встают в дерево до рендера — дальше они
|
|
46
|
+
// неотличимы от реальных папок (диаграммы, sidebar, поиск, complete).
|
|
47
|
+
await runAfterScan(tree, options, plugins);
|
|
41
48
|
// У движков без онлайн-рендера (D2, см. remoteRender в DIAGRAM_ENGINES) при
|
|
42
49
|
// выключенной локальной генерации диаграммы не во что превратить — ссылки на
|
|
43
50
|
// SVG вели бы в никуда. Падаем сразу с понятной ошибкой, а не молча битым выводом.
|
|
@@ -66,6 +73,7 @@ const build = async (options, cacheConf) => {
|
|
|
66
73
|
if (options.GENERATE_WEBSITE) {
|
|
67
74
|
console.log(chalk.blue('generating docsify site'));
|
|
68
75
|
await generateWebMD(tree, options);
|
|
76
|
+
await injectPluginAssets(plugins, options);
|
|
69
77
|
}
|
|
70
78
|
if (options.GENERATE_COMPLETE_MD_FILE) {
|
|
71
79
|
console.log(chalk.blue('generating complete markdown file'));
|
|
@@ -78,6 +86,7 @@ const build = async (options, cacheConf) => {
|
|
|
78
86
|
console.log(chalk.bold(chalk.yellow('WARNING:')));
|
|
79
87
|
console.log(chalk.yellow(`PDF-вывод больше не поддерживается. Удалите вручную из .c4builder: ${options.LEGACY_PDF_KEYS.join(', ')}.`));
|
|
80
88
|
}
|
|
89
|
+
await runAfterBuild(options, plugins);
|
|
81
90
|
console.log(chalk.green(`built in ${(Date.now() - start_date.getTime()) / 1000} seconds`));
|
|
82
91
|
ok = true;
|
|
83
92
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { encodeURIPath } from "../../util/utils.js";
|
|
4
|
+
// Вставка тегов в готовый HTML: стили — перед последним `</head>`, скрипты — перед
|
|
5
|
+
// последним `</body>` (регистронезависимо); тега нет — в конец файла (браузер исполнит
|
|
6
|
+
// и так). Пост-обработка не зависит от того, чей это шаблон (docsifyTemplate пользователя
|
|
7
|
+
// править не требуется). Экспортируется для юнит-теста.
|
|
8
|
+
export const injectHtml = (html, { styles, scripts }) => {
|
|
9
|
+
const before = (doc, closing, block) => {
|
|
10
|
+
if (!block)
|
|
11
|
+
return doc;
|
|
12
|
+
const m = [...doc.matchAll(closing)].pop();
|
|
13
|
+
return m?.index === undefined
|
|
14
|
+
? `${doc}\n${block}`
|
|
15
|
+
: `${doc.slice(0, m.index)}${block}\n${doc.slice(m.index)}`;
|
|
16
|
+
};
|
|
17
|
+
const styleTags = styles.map((s) => `<link rel="stylesheet" href="${s}">`).join('\n');
|
|
18
|
+
const scriptTags = scripts.map((s) => `<script src="${s}"></script>`).join('\n');
|
|
19
|
+
return before(before(html, /<\/head>/gi, styleTags), /<\/body>/gi, scriptTags);
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Копирует ассеты плагинов в `dist/vendor/plugins/<name>/` и подключает их в
|
|
23
|
+
* `dist/index.html`. Зовётся после generateWebMD (index.html уже записан).
|
|
24
|
+
*/
|
|
25
|
+
export const injectPluginAssets = async (plugins, options) => {
|
|
26
|
+
const indexPath = path.join(options.DIST_FOLDER, 'index.html');
|
|
27
|
+
const html = fs.readFileSync(indexPath, 'utf8');
|
|
28
|
+
// Файл с тем же именем уже подключён шаблоном (дефолтный шаблон сам грузит
|
|
29
|
+
// vendor/swagger-ui-bundle.js) — второй раз не копируем и не подключаем.
|
|
30
|
+
const linked = (file) => new RegExp(`(?:src|href)=["'][^"']*\\b${path.basename(file).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}["']`).test(html);
|
|
31
|
+
const styles = [];
|
|
32
|
+
const scripts = [];
|
|
33
|
+
for (const { plugin } of plugins) {
|
|
34
|
+
if (!plugin.assets)
|
|
35
|
+
continue;
|
|
36
|
+
const rel = path.posix.join('vendor', 'plugins', plugin.name);
|
|
37
|
+
const destDir = path.join(options.DIST_FOLDER, 'vendor', 'plugins', plugin.name);
|
|
38
|
+
const copy = (files, out) => {
|
|
39
|
+
for (const file of files ?? []) {
|
|
40
|
+
if (linked(file))
|
|
41
|
+
continue;
|
|
42
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
43
|
+
fs.copyFileSync(file, path.join(destDir, path.basename(file)));
|
|
44
|
+
out.push(encodeURIPath(path.posix.join(rel, path.basename(file))));
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
copy(plugin.assets.styles, styles);
|
|
48
|
+
copy(plugin.assets.scripts, scripts);
|
|
49
|
+
}
|
|
50
|
+
if (!styles.length && !scripts.length)
|
|
51
|
+
return;
|
|
52
|
+
fs.writeFileSync(indexPath, injectHtml(html, { styles, scripts }));
|
|
53
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { resolveSource } from "./source.js";
|
|
2
|
+
import { addPage } from "./tree.js";
|
|
3
|
+
const wrap = async (name, hook, fn) => {
|
|
4
|
+
try {
|
|
5
|
+
await fn();
|
|
6
|
+
}
|
|
7
|
+
catch (e) {
|
|
8
|
+
throw new Error(`Плагин ${name} (${hook}): ${e.message ?? e}`, { cause: e });
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
export const runAfterScan = async (tree, options, plugins) => {
|
|
12
|
+
for (const { plugin, opts } of plugins) {
|
|
13
|
+
if (!plugin.afterScan)
|
|
14
|
+
continue;
|
|
15
|
+
const ctx = {
|
|
16
|
+
tree,
|
|
17
|
+
options,
|
|
18
|
+
addPage: (page) => addPage(tree, options, page),
|
|
19
|
+
source: resolveSource
|
|
20
|
+
};
|
|
21
|
+
await wrap(plugin.name, 'afterScan', () => plugin.afterScan?.(ctx, opts));
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
export const runAfterBuild = async (options, plugins) => {
|
|
25
|
+
for (const { plugin, opts } of plugins) {
|
|
26
|
+
if (!plugin.afterBuild)
|
|
27
|
+
continue;
|
|
28
|
+
await wrap(plugin.name, 'afterBuild', () => plugin.afterBuild?.({ distFolder: options.DIST_FOLDER, options }, opts));
|
|
29
|
+
}
|
|
30
|
+
};
|